36 — 汹涌大海的着色 Shader

Three.js Journey · Bruno Simon · 难度:简单

36 — 汹涌大海的着色 Shader 课程海报

简介

在《Raging Sea》课程的结尾,我曾建议有兴趣的同学进一步为海面添加反射效果。

到现在,你应该已经意识到这个任务有多难了。

幸运的是,既然我们已经了解了基础着色(shading)的工作原理,就可以把这些知识应用到汹涌的大海上。而且更好的是,我们还能复用上一课中准备好的光照函数。

下面是最终效果:

lesson demo video preview

这也是一个让海水看起来更加史诗感的机会,仿佛海底发生了某些神秘的事情。

准备工作

本课的起始代码与《Raging Sea》课程结束时完全相同:

调整初始设置

我们要调整一下初始设置,以适应接下来的效果,并让海水看起来像前面展示的那样更史诗一些。

最初的配色是为了突出浪尖并制造对比。但这次不再需要了,因为光照着色本身就会带来大量真实而自然的对比。

我们把 depthColor 改为 #ff4000,把 surfaceColor 改为 #151c37:

debugObject.depthColor = '#ff4000'
debugObject.surfaceColor = '#151c37'

现在我们还需要更新渐变的偏移和幅度。

在 waterMaterial 的 uniforms 中,把 uColorOffset 改为 0.925,把 uColorMultiplier 改为 1:

const waterMaterial = new THREE.ShaderMaterial({
    // ...
    uniforms:
    {
        // ...
        uColorOffset: { value: 0.925 },
        uColorMultiplier: { value: 1 }
    }
})

看起来已经史诗多了。

准备 shader

在添加任何光照着色之前,我们需要先准备一下 shader,并稍微整理一下结构。

其实起始代码本可以提前准备好,但我希望你能从 Raging Sea 课程结束时的原始状态开始。

Perlin 函数

把 cnoise 函数重命名为 perlinClassic3D,别忘了同时修改你在 main() 中调用它的地方:

float perlinClassic3D(vec3 P)
{
    // ...
}

void main()
{
    // ...

    for(float i = 1.0; i <= uSmallIterations; i++)
    {
        elevation -= abs(perlinClassic3D(vec3(modelPosition.xz * uSmallWavesFrequency * i, uTime * uSmallWavesSpeed)) * uSmallWavesElevation / i);
    }
    
    // ...
}

cnoise 代表 classic noise,这没错,但不够准确。

接下来,我们把它放到另一个文件中,这样它就不会占掉顶点着色器的大部分篇幅。

在 src/shaders/ 文件夹中,创建一个 includes/ 文件夹。

在 includes/ 文件夹中,创建一个 perlinClassic3D.glsl 文件,加入该函数,以及配套的 permute、taylorInvSqrt 和 fade 函数:

// Classic Perlin 3D Noise 
// by Stefan Gustavson
//
vec4 permute(vec4 x)
{
    return mod(((x*34.0)+1.0)*x, 289.0);
}
vec4 taylorInvSqrt(vec4 r)
{
    return 1.79284291400159 - 0.85373472095314 * r;
}
vec3 fade(vec3 t)
{
    return t*t*t*(t*(t*6.0-15.0)+10.0);
}

float perlinClassic3D(vec3 P)
{
    vec3 Pi0 = floor(P); // Integer part for indexing
    vec3 Pi1 = Pi0 + vec3(1.0); // Integer part + 1
    Pi0 = mod(Pi0, 289.0);
    Pi1 = mod(Pi1, 289.0);
    vec3 Pf0 = fract(P); // Fractional part for interpolation
    vec3 Pf1 = Pf0 - vec3(1.0); // Fractional part - 1.0
    vec4 ix = vec4(Pi0.x, Pi1.x, Pi0.x, Pi1.x);
    vec4 iy = vec4(Pi0.yy, Pi1.yy);
    vec4 iz0 = Pi0.zzzz;
    vec4 iz1 = Pi1.zzzz;

    vec4 ixy = permute(permute(ix) + iy);
    vec4 ixy0 = permute(ixy + iz0);
    vec4 ixy1 = permute(ixy + iz1);

    vec4 gx0 = ixy0 / 7.0;
    vec4 gy0 = fract(floor(gx0) / 7.0) - 0.5;
    gx0 = fract(gx0);
    vec4 gz0 = vec4(0.5) - abs(gx0) - abs(gy0);
    vec4 sz0 = step(gz0, vec4(0.0));
    gx0 -= sz0 * (step(0.0, gx0) - 0.5);
    gy0 -= sz0 * (step(0.0, gy0) - 0.5);

    vec4 gx1 = ixy1 / 7.0;
    vec4 gy1 = fract(floor(gx1) / 7.0) - 0.5;
    gx1 = fract(gx1);
    vec4 gz1 = vec4(0.5) - abs(gx1) - abs(gy1);
    vec4 sz1 = step(gz1, vec4(0.0));
    gx1 -= sz1 * (step(0.0, gx1) - 0.5);
    gy1 -= sz1 * (step(0.0, gy1) - 0.5);

    vec3 g000 = vec3(gx0.x,gy0.x,gz0.x);
    vec3 g100 = vec3(gx0.y,gy0.y,gz0.y);
    vec3 g010 = vec3(gx0.z,gy0.z,gz0.z);
    vec3 g110 = vec3(gx0.w,gy0.w,gz0.w);
    vec3 g001 = vec3(gx1.x,gy1.x,gz1.x);
    vec3 g101 = vec3(gx1.y,gy1.y,gz1.y);
    vec3 g011 = vec3(gx1.z,gy1.z,gz1.z);
    vec3 g111 = vec3(gx1.w,gy1.w,gz1.w);

    vec4 norm0 = taylorInvSqrt(vec4(dot(g000, g000), dot(g010, g010), dot(g100, g100), dot(g110, g110)));
    g000 *= norm0.x;
    g010 *= norm0.y;
    g100 *= norm0.z;
    g110 *= norm0.w;
    vec4 norm1 = taylorInvSqrt(vec4(dot(g001, g001), dot(g011, g011), dot(g101, g101), dot(g111, g111)));
    g001 *= norm1.x;
    g011 *= norm1.y;
    g101 *= norm1.z;
    g111 *= norm1.w;

    float n000 = dot(g000, Pf0);
    float n100 = dot(g100, vec3(Pf1.x, Pf0.yz));
    float n010 = dot(g010, vec3(Pf0.x, Pf1.y, Pf0.z));
    float n110 = dot(g110, vec3(Pf1.xy, Pf0.z));
    float n001 = dot(g001, vec3(Pf0.xy, Pf1.z));
    float n101 = dot(g101, vec3(Pf1.x, Pf0.y, Pf1.z));
    float n011 = dot(g011, vec3(Pf0.x, Pf1.yz));
    float n111 = dot(g111, Pf1);

    vec3 fade_xyz = fade(Pf0);
    vec4 n_z = mix(vec4(n000, n100, n010, n110), vec4(n001, n101, n011, n111), fade_xyz.z);
    vec2 n_yz = mix(n_z.xy, n_z.zw, fade_xyz.y);
    float n_xyz = mix(n_yz.x, n_yz.y, fade_xyz.x); 
    return 2.2 * n_xyz;
}

回到 vertex.glsl,把所有这些部分替换成一个 #include:

#include ../includes/perlinClassic3D.glsl

void main()
{
    // ...
}

注释

在顶点着色器中添加一些注释,稍微分隔一下各部分:

void main()
{
    // Base position
    vec4 modelPosition = modelMatrix * vec4(position, 1.0);

    // Elevation
    float elevation = sin(modelPosition.x * uBigWavesFrequency.x + uTime * uBigWavesSpeed) *
                      sin(modelPosition.z * uBigWavesFrequency.y + uTime * uBigWavesSpeed) *
                      uBigWavesElevation;

    for(float i = 1.0; i <= uSmallIterations; i++)
    {
        elevation -= abs(perlinClassic3D(vec3(modelPosition.xz * uSmallWavesFrequency * i, uTime * uSmallWavesSpeed)) * uSmallWavesElevation / i);
    }
    
    modelPosition.y += elevation;

    // Final position
    vec4 viewPosition = viewMatrix * modelPosition;
    vec4 projectedPosition = projectionMatrix * viewPosition;
    gl_Position = projectedPosition;

    // Varyings
    vElevation = elevation;
}

在片元着色器中也做同样的事:

void main()
{
    // Base color
    float mixStrength = (vElevation + uColorOffset) * uColorMultiplier;
    vec3 color = mix(uDepthColor, uSurfaceColor, mixStrength);

    // Final color
    gl_FragColor = vec4(color, 1.0);
    #include <colorspace_fragment>
}

这听起来可能有点多余,但我们接下来会往 vertex.glsl 里添加不少代码,在它变得复杂之前保持结构清晰总是好的。

Smoothstep

在片元着色器中,我们通过对 mixStrength 应用 smoothstep 来增强渐变的观感:

void main()
{
    // Base color
    float mixStrength = (vElevation + uColorOffset) * uColorMultiplier;
    mixStrength = smoothstep(0.0, 1.0, mixStrength);
    // ...
}

色调映射

虽然我们目前没有对渲染器使用任何色调映射(tone mapping),但我们可以提前考虑它,把 tonemapping_fragment chunk 加在 colorspace_fragment chunk 之前:

void main()
{
    // ...

    // Final color
    gl_FragColor = vec4(color, 1.0);
    #include <tonemapping_fragment>
    #include <colorspace_fragment>
}

灯光

最后,既然我们要用到灯光,就把上一课的光照 includes 也加到我们的 includes/ 文件夹中。

src/shaders/includes/ambientLight.glsl:

vec3 ambientLight(vec3 lightColor, float lightIntensity)
{
    return lightColor * lightIntensity;
}

src/shaders/includes/directionalLight.glsl:

vec3 directionalLight(vec3 lightColor, float lightIntensity, vec3 normal, vec3 lightPosition, vec3 viewDirection, float specularPower)
{
    vec3 lightDirection = normalize(lightPosition);
    vec3 lightReflection = reflect(- lightDirection, normal);

    // Shading
    float shading = dot(normal, lightDirection);
    shading = max(0.0, shading);

    // Specular
    float specular = - dot(lightReflection, viewDirection);
    specular = max(0.0, specular);
    specular = pow(specular, specularPower);

    return lightColor * lightIntensity * (shading + specular);
}

src/shaders/includes/pointLight.glsl:

vec3 pointLight(vec3 lightColor, float lightIntensity, vec3 normal, vec3 lightPosition, vec3 viewDirection, float specularPower, vec3 position, float lightDecay)
{
    vec3 lightDelta = lightPosition - position;
    float lightDistance = length(lightDelta);
    vec3 lightDirection = normalize(lightDelta);
    vec3 lightReflection = reflect(- lightDirection, normal);

    // Shading
    float shading = dot(normal, lightDirection);
    shading = max(0.0, shading);

    // Specular
    float specular = - dot(lightReflection, viewDirection);
    specular = max(0.0, specular);
    specular = pow(specular, specularPower);

    // Decay
    float decay = 1.0 - lightDistance * lightDecay;
    decay = max(0.0, decay);

    return lightColor * lightIntensity * decay * (shading + specular);
}

平行光

我们要添加一个平行光光源。如果你还记得上一课,我们需要法线和视线方向。

法线

先从法线开始。

在 vertex.glsl 中,创建一个 vec3 vNormal varying:

varying vec3 vNormal;

这次,我们不要犯那个常见的错误——忘记把模型变换应用到法线上。

把 modelMatrix 应用到 normal 上,把它的第四个值设为 0.0,并赋值给 vNormal,但由于 vNormal 是 vec3,只对 xyz 做 swizzle:

void main()
{
    // ...

    // Varyings
    vElevation = elevation;
    vNormal = (modelMatrix * vec4(normal, 0.0)).xyz;
}

在 fragment.glsl 中,同样,我们不要犯忘记再次归一化法线的错误。

取出 vNormal varying,对它使用 normalize,并把结果保存为 vec3 normal:

varying float vElevation;
varying vec3 vNormal;

void main()
{
    vec3 normal = normalize(vNormal);
    
    // ...
}

我们的法线应该准备好了。对吧?

嗯,其实并不完全对,但我们先往下走,很快就会看到问题出在哪里。

视线方向

光照接下来需要的是视线方向。它是从摄像机指向片元位置的向量。但首先,我们需要片元位置。

在 vertex.glsl 中,创建一个 vec3 vPosition varying,并赋值为 modelPosition.xyz:

varying float vElevation;
varying vec3 vNormal;
varying vec3 vPosition;

// ...

void main()
{
    // ...

    // Varyings
    vElevation = elevation;
    vNormal = (modelMatrix * vec4(normal, 0.0)).xyz;
    vPosition = modelPosition.xyz;
}

在 fragment.glsl 中取出它:

varying float vElevation;
varying vec3 vNormal;
varying vec3 vPosition;

因为视线方向是从摄像机指向片元位置的向量,我们可以用 vPosition 减去 cameraPosition。别忘了归一化它:

void main()
{
    vec3 viewDirection = normalize(vPosition - cameraPosition);
    vec3 normal = normalize(vNormal);

    // ...
}

实现平行光

平行光所需的东西我们应该都齐了。

首先 include directionalLight 函数:

#include ../includes/directionalLight.glsl

void main()
{
    // ...
}

在 main() 中,创建一个初始值为 vec3(0.0) 的 vec3 light,用下面这些参数把 directionalLight 的结果累加到它上面,然后把它与 color 相乘:

void main()
{
    // ...

    // Light
    vec3 light = vec3(0.0);

    light += directionalLight(
        vec3(1.0),            // Light color
        1.0,                  // Light intensity,
        normal,               // Normal
        vec3(-1.0, 0.5, 0.0), // Light position
        viewDirection,        // View direction
        30.0                  // Specular power
    );

    color *= light;
    
    // ...
}

结果并不符合预期。

计算法线

如果你旋转摄像机,应该会注意到颜色出现了一些变化,像是一片没有跟随海面真实表面的大反射。

lesson demo video preview

实际情况是,所有的法线目前都朝上,完全忽略了海浪的实际形状:

你可以很容易地通过把 normal 传给 gl_FragColor 来验证这一点:

void main()
{
    // ...
    gl_FragColor = vec4(normal, 1.0);
    // ...
}

如果它是绿色的,说明 g 值等于 1.0,而 r 和 b 值等于 0.0。由于 g 对应 y 轴,所以所有法线都朝上。

原因是:我们使用的是几何体属性中的 normal,而那些法线并不受海浪影响。我们不能直接更新这个属性,因为它需要根据我们在顶点着色器中计算的海浪在每一帧发生变化。

我们需要自己计算新的朝向。

原理

计算法线有多种方式,取决于很多因素,但在网格(grid)的情况下,我们可以使用邻居(neighbours)技巧。

我们将忽略几何体自带的 normal 属性,改为计算邻居的理论位置来计算法线。

一个邻居位于 x 轴上更远的位置(我们称它为 A),另一个邻居位于 z 轴上更远的位置(我们称它为 B):

A 和 B 纯粹是理论上的。我们看不到它们,但会计算它们的位置。

然后我们像处理当前顶点那样,更新这些邻居的高程:

我们计算一个从顶点指向 A 的向量(我们称它为 toA),再计算一个从顶点指向 B 的向量(我们称它为 toB):

我们计算出的法线应该是同时垂直于 toA 和 toB 的向量,好消息是有一个叫叉积(cross product)的函数正好能做这件事:

把高程写成函数

由于我们需要计算 3 个位置的高程(当前顶点和两个邻居),最好把计算放进一个函数里,以免重复太多代码。

在 vertex.glsl 中、main() 之前,创建一个 waveElevation 函数,接受一个 vec3 position 作为唯一参数,并返回一个 float(高程):

float waveElevation(vec3 position)
{
    
}

void main()
{
    // ...
}

我们不需要把这段代码放进 include,因为它对这个 shader 非常特定。

在该函数中,把所有与 elevation 计算相关的代码放进去,把 modelPosition 替换为 position(参数),并返回 elevation:

float waveElevation(vec3 position)
{
    float elevation = sin(position.x * uBigWavesFrequency.x + uTime * uBigWavesSpeed) *
                      sin(position.z * uBigWavesFrequency.y + uTime * uBigWavesSpeed) *
                      uBigWavesElevation;

    for(float i = 1.0; i <= uSmallIterations; i++)
    {
        elevation -= abs(perlinClassic3D(vec3(position.xz * uSmallWavesFrequency * i, uTime * uSmallWavesSpeed)) * uSmallWavesElevation / i);
    }
    
    return elevation;
}

在 main() 中,把整个 elevation 部分替换为对这个函数的调用,并把 modelPosition.xyz 传给它:

void main()
{
    // ...

    // Elevation
    float elevation = waveElevation(modelPosition.xyz);
    modelPosition.y += elevation;

    // ...
}

海浪的效果应该和之前完全一样。

坐标轴辅助

在计算邻居之前,我们先添加一个 AxesHelper,以便理解各个坐标轴的方向。

在 script.js 中、创建 scene 之后,添加一个 AxesHelper 并稍微向上移动它:

// Scene
const scene = new THREE.Scene()

// Axes helper
const axesHelper = new THREE.AxesHelper()
axesHelper.position.y += 0.25
scene.add(axesHelper)

邻居的基础位置

首先,我们需要确定要计算的邻居的距离。

我们把这个距离称为 shift。

我们本可以使用到下一个顶点的实际距离,但记住,那些邻居纯粹是理论上的,我们可以尝试更小或更大的距离。

shift 较小,我们可能会捕捉到最终海浪中看不见的高程细节。

shift 较大,我们可能会漏掉高程细节。

一般规则是:把 shift 取得足够小,以捕捉最小的海浪细节。在看到结果之后,再随意调节 shift 以获得最佳效果。现在,我们先把它设为 0.01。

在 vertex.glsl 中、设置 modelPosition 之前,创建一个 float shift 变量并设为 0.01:

void main()
{
    // Base position
    float shift = 0.01;
    vec4 modelPosition = modelMatrix * vec4(position, 1.0);

    // ...
}

现在我们需要创建两个邻居的基础位置。先从 x 轴上的邻居 A 开始。

我们以 modelPosition 为起点,使用 shift 在 x 上稍微偏移它。我们称它为 modelPositionA,它将是一个 vec3:

void main()
{
    // Base position
    float shift = 0.01;
    vec4 modelPosition = modelMatrix * vec4(position, 1.0);
    vec3 modelPositionA = modelPosition.xyz + vec3(shift, 0.0, 0.0);

    // ...
}

modelPosition 是 vec4,因为我们之后要对其应用矩阵,但 modelPositionA 不需要任何矩阵,所以 vec3 就够了。

目前我们看不到任何视觉变化。但这不应妨碍你保存并检查 shader 中是否有错误。

我们对 z 轴上的邻居也做同样的事。称它为 modelPositionB,但用 - shift 把它移动到负的 z 方向:

void main()
{
    // Base position
    float shift = 0.01;
    vec4 modelPosition = modelMatrix * vec4(position, 1.0);
    vec3 modelPositionA = modelPosition.xyz + vec3(shift, 0.0, 0.0);
    vec3 modelPositionB = modelPosition.xyz + vec3(0.0, 0.0, - shift);

    // ...
}

你可能会疑惑:为什么是负的 z 而不是正的 z?这是因为叉积的缘故,但在讨论这个话题之前,我们还需要再写几行代码。

邻居的高程

我们现在可以用 waveElevation 函数来计算邻居的高程,并像处理 modelPosition 那样更新它们的 y 轴:

void main()
{
    // ...

    // Elevation
    float elevation = waveElevation(modelPosition.xyz);
    modelPosition.y += elevation;
    modelPositionA.y += waveElevation(modelPositionA);
    modelPositionB.y += waveElevation(modelPositionB);

    // ...
}

计算邻居的方向

现在我们知道了邻居在哪里,还需要从顶点指向这些邻居的向量。

一如既往,从 A 到 B 的向量可以通过用 B 减去 A 来计算:

我们把这两个向量称为 toA 和 toB:

void main()
{
    // ...

    // Compute normal
    vec3 toA = modelPositionA - modelPosition.xyz;
    vec3 toB = modelPositionB - modelPosition.xyz;

    // ...
}

我们将把这些向量用于叉积函数,而它要求向量是归一化的(长度为 1),这可以通过调用 normalize 来实现:

void main()
{
    // ...

    // Compute normal
    vec3 toA = normalize(modelPositionA - modelPosition.xyz);
    vec3 toB = normalize(modelPositionB - modelPosition.xyz);

    // ...
}

计算法线

我们已经可以用前面聊了很久的叉积函数来计算法线了。但在那之前,我们先尝试理解并直观感受一下这个函数。

叉积会输出一个同时垂直于两个输入向量的向量。

一个简单又“顺手”的可视化方法是使用你的右手。注意,用左手是不行的。

把右手放在面前并握拳:

竖起你的拇指,这就是你的邻居 A:

伸出你的食指指向前方,这就是你的邻居 B:

现在以 90° 的角度伸出你的中指,这就是你的叉积。

这就是如何根据 A 和 B 的方向来直观判断叉积会指向哪里。

你可以移动你的手,也可以缩小拇指和食指之间的夹角,但只要你的中指与两者都保持 90° 角,那它就是叉积。

这也是为什么我们把邻居 B 偏移到负的 z 而不是正的 z。试着转动你的手去匹配 A 和 B 两个邻居,你会发现在正的 z 下,叉积会指向下方。

说得够多了,手势也够多了,我们来计算那条法线吧。

创建一个 vec3 computeNormal 变量,调用 cross 函数,并把 toA 和 toB 传给它:

void main()
{
    // ...

    // Compute normal
    vec3 toA = normalize(modelPositionA - modelPosition.xyz);
    vec3 toB = normalize(modelPositionB - modelPosition.xyz);
    vec3 computedNormal = cross(toA, toB);

    // ...
}

把它赋值给 vNormal:

void main()
{
    // ...
    vNormal = computedNormal;
    // ...
}
lesson demo video preview

这是一条看起来很不错的法线。

它仍然大体是绿色的,因为表面总体朝上,但我们会根据表面的朝向得到一些变化。

最后润色

我们快完成了。让我们找回基础颜色并稍作改进。

移除法线属性

既然我们是自己计算法线,就可以用 BufferGeometry 及其任何继承类上可用的 deleteAttribute 方法,从几何体中移除 normal 属性:

const waterGeometry = new THREE.PlaneGeometry(2, 2, 512, 512)
waterGeometry.deleteAttribute('normal')

法线不受影响,同时我们释放了一些内存。

颜色

在 fragment.glsl 中,把 color 放回 gl_FragColor:

void main()
{
    // ...
    gl_FragColor = vec4(color, 1.0);
    // ...
}
lesson demo video preview

点光源

平行光看起来不错,但一个位于海面中心略上方的点光源会更好看。

把 #include ../includes/directionalLight.glsl 替换为 #include ../includes/pointLight.glsl,并用以下参数调用 pointLight() 函数:

#include ../includes/pointLight.glsl

void main()
{
    // ...

    light += pointLight(
        vec3(1.0),            // Light color
        10.0,                 // Light intensity,
        normal,               // Normal
        vec3(0.0, 0.25, 0.0), // Light position
        viewDirection,        // View direction
        30.0,                 // Specular power
        vPosition,            // Position
        0.95                  // Decay
    );

    // ...
}

坐标轴辅助

移除 AxesHelper 实例:

// // Axes helper
// const axesHelper = new THREE.AxesHelper()
// axesHelper.position.y += 0.25;
// scene.add(axesHelper)

色调映射

在 renderer 上,把 toneMapping 设置为 THREE.ACESFilmicToneMapping,让颜色更有冲击力:

const renderer = new THREE.WebGLRenderer({
    canvas: canvas
})
renderer.toneMapping = THREE.ACESFilmicToneMapping
lesson demo video preview

总结

这节课到此结束。与最初的汹涌大海相比,这是一次巨大的改进。

记住,计算法线取决于初始数据,如果不是因为我们用的是网格,我们就无法这么轻松地使用邻居技巧。

之后我们会在更复杂的几何体上计算法线,但那是另一节课的内容了。

过程中也很容易出错,因为我们写了很多代码却看不到屏幕上的任何东西。

为了学习和理解,我们经历了很多步骤、创建了很多变量,但代码其实可以大幅精简。欢迎你自己去优化它。

最后,这样计算法线是有代价的。我们把海浪高程公式执行了三次,而每一次又都会计算一堆 perlin 噪声。要留意性能,不要放太多顶点,也不要过多地调用 perlin 噪声函数。

延伸学习

一如既往,欢迎你在这节课的基础上改进。

这里有一些建议: